---
title: 分页
slug: pagination
date: 0012/01/01
number: 12
points: 10
photoUrl: http://www.flickr.com/photos/ikewinski/8625379401/
photoAuthor: Mike Lewinski
contents: 进一步学习 Meteor 的订阅, 学习如何控制数据订阅。|实现无限风格分页|使用包 `iron-router-progress` 实现一个 iOS 风格的进度条。|为直接指向帖子的链接创建特殊的订阅。
paragraphs: 67
---

Microscope 的功能看起来不错。我们可以想象当它 release 之后会很受欢迎。

因此我们需要考虑一下随着新帖子越来越多所带来的性能问题。

之前我们说过客户端集合会包含服务器端数据的一个子集。我们在帖子和评论集合已经实现了这些。

但是现在，如果我们还是一口气发布所有帖子给所有的连接用户。当有成千上万的新帖子时，这会带来一些问题。为了解决这些，我们需要给帖子分页。

### 添加更多的帖子

首先是我们的初始化数据，我们需要添加足够的帖子来使分页有意义：

~~~js
// Fixture data
if (Posts.find().count() === 0) {

  //...

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000),
    commentsCount: 0
  });

  for (var i = 0; i < 10; i++) {
    Posts.insert({
      title: 'Test post #' + i,
      author: sacha.profile.name,
      userId: sacha._id,
      url: 'http://google.com/?q=test-' + i,
      submitted: new Date(now - i * 3600 * 1000),
      commentsCount: 0
    });
  }
}
~~~
<%= caption "server/fixtures.js" %>
<%= highlight "15~24" %>

运行完 `meteor reset` 重启你的 app, 你会看到如下：

<%= screenshot "12-1", "显示初始化数据。" %>

<%= commit "12-1", "添加足够的帖子使分页有必要。" %>

### 无限分页

我们将实现一个"无限"的分页。意思是在第一屏显示 10 条帖子和一个在底部显示的 "load more" 链接。点击 "load more" 链接再加载另外 10 条帖子，诸如此类*无限的加载*。这意味着我们只用一个参数来实现分页，控制在屏幕上显示帖子的数量。

现在需要一个方法告诉服务器端返回给客户端帖子的数量。这些发生在路由订阅`帖子`的过程，我们会利用路由来实现分页。

最简单的限制返回帖子数量的方式是将返回数量加到 URL 中，如 `http://localhost:3000/25`。使用 URL 记录数量的另一个好处是，如果不小心刷新了页面，还会返回 25 条帖子。

为了恰当的实现分页，我们需要修改帖子的订阅方法。就像我们之前在*评论*那章做的，我们需要将订阅部分的代码从 *router* 级变为 *route* 级。

这个改变内容会比较多，通过代码可以看的比较清楚。

首先，停止 `Router.configure()` 代码块中的 `posts` 订阅。即删除 `Meteor.subscribe('posts')`,只留下 `notifications` 订阅:

~~~js
Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('notifications')]
  }
});
~~~
<%= caption "lib/router.js" %>
<%= highlight "6" %>

我们在路由路径中加入参数 `postsLimt`。 参数后面的 `?` 表示参数是可选的。这样路由就能同时匹配 `http://localhost:3000/50` 和 `http://localhost:3000`。

~~~js
//...

Router.route('/:postsLimit?', {
  name: 'postsList',
});

//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "3" %>

需要注意每个路径都会匹配路由 `/:parameter?`。因为每个路由都会被检查是否匹配当前路径。我们要组织好路由来减少特异性。

话句话说，更特殊的路由会优先选择，例如:路由 `/posts/:_id` 会在前面，而路由 `postsList` 会放到路由组的最后，因为它太泛泛了可以匹配所有路径。

是时候处理难题了，处理订阅和找到正确的数据。我么需要处理 `postsLimit` 参数不存在的情况。我们给它一个默认值 5, 这样我们能更好的演示分页。

~~~js
//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  }
});

//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "5~8" %>

你注意到我们在订阅 `posts` 时传了一个 js 对象 ({sort: {submitted: -1}, limit: postsLimit}), 这个 js 对象会作为服务器端查询方法 `Posts.find()` 的可选参数。下面是服务器端的实现代码：

~~~js
Meteor.publish('posts', function(options) {
  check(options, {
    sort: Object,
    limit: Number
  });
  return Posts.find({}, options);
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId});
});
~~~
<%= caption "server/publications.js" %>
<%= highlight "1~7" %>

<% note do %>

### 传递参数

我们的订阅代码告诉服务器端，我们信任客户端传来的 JavaScript 对象 (在我们的例子中是 `{limit: postsLimit}`) 作为 `find()` 方法的 `options` 参数。这样我们能通过 browser consle 来传任何 option 对象。

在我们的例子中,这样没什么害处，因为用户可以做的无非是改变帖子顺序，或者修改 limit 值（这是我们想让用户做的）。但是对于一个 real-world app 我们必须做必要的限制！

幸好通过 `check()` 方法我们知道用户不能偷偷加入额外的 options （例如 `fields`, 在某些情况下需要对外暴露 ducoments 的私有数据）。

然而，更安全的做法是传递单个参数而不是整个对象，通过这样确保数据安全：

~~~js
Meteor.publish('posts', function(sort, limit) {
  return Posts.find({}, {sort: sort, limit: limit});
});
~~~

<% end %>

现在我们在 route 级订阅数据，同样的我们可以在这里设置数据的 context。我们要偏离一下之前的模式，我们让 `data` 函数返回一个 js 对象而不是一个 cursor。 这样我们可以创建一个*命名*的数据 context。我们称之为 `posts`。

这意味着我们的数据 context 将存在于 `posts` 中，而不是简单的在模板中隐式的存在于 `this` 中。除去这一点，代码看起来很相似:

~~~js
//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "9~14" %>

因为我们在 route 级设置数据 context, 现在我们可以去掉在 `posts_list.js` 文件中 `posts` 模板的帮助方法。

我们的数据 context 叫做 `posts` （和 helper 同名），所以我们甚至不需要修改 `postsList` 模板！

下面是我们修改过的 `router.js` 代码：

~~~js
Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('notifications')]
  }
});

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return Meteor.subscribe('comments', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn()) {
      this.render(this.loadingTemplate);
    } else {
      this.render('accessDenied');
    }
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
~~~
<%= caption "lib/router.js" %>
<%= highlight "6,25~37" %>

<%= commit "12-2", "为 postsList 路径添加限制。" %>

试一下我们的分页。现在我们可以通过 URL 参数来控制页面显示帖子的数量，试一下 `http://localhost:3000/3`。你可以看到如下:

<%= screenshot "12-2", "控制首页显示帖子的数量。" %>

<% note do %>

### 为什么不用传统的分页？

为什么我们使用“无限分页”而不用每页显示 10 条帖子的连续分页,就像 Google 的搜索结果分页一样？这是由于 Meteor 的实时性决定的。

让我们想象一下使用类似 Google 搜索结果的连续分页模式，我们在第2页，显示的是 10 到 20 条帖子。这是碰巧有另外一个用户删除了前面 10 条帖子中的帖子。

因为 app 是实时的，我们的数据集会马上变化，这样第 10 条帖子变成了第 9 条，从当前页面消失了，第 21 条帖子会出现在页面中。这样用户会觉得没什么原由的结果集变了!

即使我们可以容忍这种怪异的 UX, 由于技术的原因传统的分页还是很难实现。

让我们回到前一个例子。我们从 `Posts` 集合中发布第 10 到 20 条帖子，但是在客户端我们如何找到这些帖子？我们不能在客户端选择第 10 到 20 条帖子，因为客户端集合只有 10 个帖子。

一个简单的方案是服务器端发布 10 条帖子，在客户端执行一下 `Posts.find()` 找到这 10 条发布的帖子。

这个方案在只有一个用户订阅的情况下有效，但是如果有多个用户订阅呢，下面我们会看到。

我们假设一个用户需要第 10 到 20 条帖子，而另一个需要第 30 到 40。这样在客户端我们有两个 20 条帖子，我们不能区分他们属于哪个订阅。

基于这些原因，我们在 Meteor 中不能使用传统的分页。

<% end %>

### 创建路由控制器（Route controller）

你可能已经注意到了我们代码中重复了 `var limit = parseInt(this.params.postsLimit) || 5;` 两次。而且硬编码数字 5，这不是个理想的做法。虽然这不会导致世界末日，但是我们最好还是遵循 DRY 原则 (Don't Repeat Yourself), 让我们看看如何能把代码重构的更好些。

我们将介绍 Iron Router 的一个新功能， *Route Controllers*。Route controller 是通过简单的方式将一组路由特性打包，其他的 route 可以继承他们。现在我们只在一个路由中使用它，在下一章我们会看到它如何派上用场。

~~~js
//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5,
  postsLimit: function() {
    return parseInt(this.params.postsLimit) || this.increment;
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  data: function() {
    return {posts: Posts.find({}, this.findOptions())};
  }
});

//...

Router.route('/:postsLimit?', {
  name: 'postsList'
});

//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "3~18, 25" %>

让我们一步接一步的往下看。首先，我们的创建一个继承 `RouteController` 的控制器。然后像之前一样设置 `template` 属性，然后添加一个新的 `increment` 属性。

然后我们定义一个 `postsLimit` 函数用来返回当前限制的数量，然后定义一个 `findOptions` 函数用来返回 options 对象。这看起来像是个对于的步骤，但是我们后面会用到它。

接下来我们定义 `waitOn` 和 `data` 函数，除了他们现在会用到新的 `findOptions` 函数外其余和之前相同。

因为我们的控制器叫做 `PostsListController` 路由叫做 `postsList`, Iron Router 会自动使用他们。因此我们只需要从路由定义中移除 `waitOn` 和 `data` (因为路由已经会处理他们了)。如果我们需要给路由起别的名字,我们可以使用 `controller` 选项(我们将在下一章看到一个例子)。

<%= commit "12-3", "重构 postsLists 路由为 RouteController." %>

### 添加加载更多链接

我们现在实现了分页，代码看起来还不错。只有一个问题：我们的分页需要手工修改 URL。这显然不是一个好的用户体验，现在让我们来修改它。

我们要做的很简单。我们将在帖子列表的下面加一个 "load more" 按钮，点击按钮将增加 5 条帖子。如果当前的 URL 是 `http://localhost:3000/5`, 点击 "load more" 按钮 URL 将变成 `http://localhost:3000/10`。如果你之前已经实现过这种功能，我们相信你很强！

因为在前面，我们的分页逻辑是在 route 中。记得我们是什么时候显式命名数据上下文，而非使用匿名 cursor 的么? 没有规则说我们的 `data` 函数只能使用 cursors, 因此，我们将用同样的技巧来生成 "load more" 按钮的 URL。

~~~js
//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5,
  postsLimit: function() {
    return parseInt(this.params.postsLimit) || this.increment;
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "15~25" %>

让我们来深入的看一下 router 带来的魔术。记住 `postsList` route (它将继承 `PostsListController` 控制器) 使用一个 `postsLimit` 参数。

因此当我们给 `this.route.path()` 传递参数 `{postsLimit: this.postsLimit() + this.increment}` 时，我们告诉 `postsList` route 使用这个 js 对象做数据上线文建立自己的 path。

换句话说，这和使用 `{{pathFor 'postsList'}}` Spacebars 帮助方法一样， 除了我们用自己的数据上下文替换了隐式的 `this`。

我们使用这个路径并将它添加到我们模板的数据上下文中，但是*只有*多条帖子时会显示。我们的实现方法有一点小花招。

我们知道 `this.limit()` 方法会返回当前我们想要显示帖子的数量，它可能是当前 URL 中的值，如果 URL 中没有参数它会是默认值 (5)。

另一方面, `this.posts` 引用当前的 cursor, 因此 `this.posts.count()` 的值是在 cursor 中帖子的数量。

因此我们说当我们要求发挥 `n` 条帖子，实际返回了 `n` 条帖子，我们将继续显示 "load more" 按钮。但是如果我们要求返回 `n` 条帖子，而实际返回的数量比 `n` 少，这样我们就知道记录已经到头了，我们就不再显示加载按钮。

这就是说，我们的系统在一种情况下会有点问题:当我们的数据库*恰好*有 `n` 条记录时。如果是这样，当客户端要求返回 `n` 条帖子，我们得到了 `n` 条，然后继续显示 "load more" 按钮，这是我们不知道其实已经没有记录可以继续返回了。

不幸的是，我们没有好的方法去解决这个问题，因此我们不得不接受这个不算完美的实现方式。

下面剩下的就是在帖子列表下面加上 "load more" 链接，并且保证在还有帖子时才显示它:

~~~html
<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>
~~~
<%= caption "client/templates/posts/posts_list.html" %>
<%= highlight "7~10" %>

下面是你帖子列表现在看上去的样子:

<%= screenshot "12-3", "“load more” 按钮。 " %>

<%= commit "12-4", "添加 nextPath() 到控制器中并使用它来取得更多帖子。" %>

### 更好的用户体验

现在我们的分页可以工作了，但是有个烦人小问题: 每次我们点击 "load more" 按钮向 router 加载更多的帖子时，Iron Router 的 `waitOn` 特性会在我们等待时显示 `loading` 模板。当结果到来时我们又会回到页面的顶端，我们每次都要滚动页面回到之前看的位置。

因此，首先我们要告诉 Iron Router 不要 `waintOn` 订阅，我们将定义自己的订阅在一个 `subscriptions` hook 中。

注意我们我们不是在 hook 中*返回*这个订阅。返回它（这是一般 `订阅` hook 常做的工作）将触发一个全局的 loading hook, 这正是我们想要避免的。我们只是想在 `subscriptions` hook 中定义我们的订阅，就像使用一个 `onBeforeAction` hook。

我们还要在我们的数据上下文中传入一个 `ready` 变量，它指向 `this.postsSub.ready`。它会告诉我们帖子订阅何时加载完毕。

~~~js
//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5,
  postsLimit: function() {
    return parseInt(this.params.postsLimit) || this.increment;
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  subscriptions: function() {
    this.postsSub = Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      ready: this.postsSub.ready,
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "12~14, 23" %>

我们将在模板中检查 `ready` 变量的状态，并在加载帖子时在帖子列表的下面显示一个加载图标(spinner):

~~~html
<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{else}}
      {{#unless ready}}
        {{> spinner}}
      {{/unless}}
    {{/if}}
  </div>
</template>
~~~
<%= caption "client/templates/posts/posts_list.html" %>
<%= highlight "10~12" %>

<%= commit "12-5", "添加加载图标使分页更好。" %>

### 访问任何帖子

现在我们默认每次加载 5 条新帖子，但是当用户访问某个帖子的单独页面时会发生什么?

<%= screenshot "12-4", "一个空模板。" %>

试一下，我们会得到一个 "not found" 错误。这是有原因的: 我们告诉 router 当我们加载 `postList` route 时订阅 `帖子` 发布。但是我们没有说访问 `postPage` route 时该做什么。

但是到目前，我们知道如何订阅一个 `n` 个最新帖子的列表。我们如何向服务器端要求单个具体帖子的内容? 我们将告诉你一个小秘密: 对于一个 collection 你可以有多个 publication！

让我们找回丢失的帖子，我们定义一个新的 publication `singlePost`,它只发布一个帖子，用 `_id` 鉴别。

~~~js
Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Meteor.publish('singlePost', function(id) {
  check(id, String)
  return Posts.find(id);
});

//...
~~~
<%= caption "server/publications.js" %>
<%= highlight "5~7" %>

现在，让我们在客户端订阅正确的帖子。我们已经在 `postPage` route 的 `wainOn` 函数中订阅了 `comments` 发布，因此我们可以也在这里加入 `singlePost` 订阅。让后别忘了在 `postEdit` route 中加入我们的订阅, 因为那里也需要相同的数据:

~~~js
//...

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return [
      Meteor.subscribe('singlePost', this.params._id),
      Meteor.subscribe('comments', this.params._id)
    ];
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  waitOn: function() {
    return Meteor.subscribe('singlePost', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "6~9,16~18" %>

<%= commit "12-6","使用单一帖子订阅来确保我们总会看到正确的帖子。" %>

有了分页，我们的程序将不再受规模问题的困扰了，用户可以加入更多的帖子。如果有某种方法可以给帖子链接加上等级 (rank) 不是更好么?我们将在下一章去实现它！
